package com.meringer.beans.ejb;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.ejb.LocalBean;
import javax.ejb.Lock;
import javax.ejb.LockType;
import javax.ejb.Schedule;
import javax.ejb.Singleton;
import javax.ejb.Startup;
import javax.ejb.Timeout;
import javax.ejb.Timer;
import javax.ejb.TimerService;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;

import com.meringer.entities.Car;
import com.meringer.entities.Race;
import com.meringer.entities.RaceResult;
import com.meringer.entities.Road;
import com.meringer.entities.User;

/**
 * Singleton bean for managing races.
 * @author K.Zlatanov
 */
@Startup
@Singleton
@LocalBean
public class RacesEJB {

    @PersistenceContext
    private EntityManager em;
	
    @Resource
    private TimerService timerService;
    
    private List<Race> racesList;
    private Map<Long, Timer> raceCountdownTimers = new HashMap<Long, Timer>();
    
	/**
     * Default constructor. 
     */
    public RacesEJB() {
    }
    
    @PostConstruct
    private void initRaces()
    {
    	TypedQuery<Road> q =
    	em.createQuery("SELECT r FROM Road r", Road.class);
    	List<Road> roadList = q.getResultList();
    	racesList = new ArrayList<Race>();
    	for (Road road : roadList)
    	{
    		Race race = new Race(road, road.getDescription());
    		// Persist and detach race to populate raceID
    		em.persist(race);
    		em.detach(race);
    		racesList.add(race);
    		sortRacesList();
    	}
    }
    
    /**
     * Sorts the races according to their road's names
     */
    private void sortRacesList()
    {
    	Collections.sort(this.racesList, new Comparator<Race>() {
			public int compare(Race r1, Race r2){
				return r1.getRoad().getName().compareToIgnoreCase(r2.getRoad().getName()); 
			}
		});
    }
    
    /**
     * Checks if new roads have been created in DB, creates new races for 
     * them in that case and adds them to the races list. 
     */
    @Schedule(minute="*/1", hour="*", persistent=false)
    private void checkForNewRoads()
    {
    	boolean newRaces = false;
    	
    	TypedQuery<Road> q =
    	em.createQuery("SELECT r FROM Road r", Road.class);
    	List<Road> roadList = q.getResultList();
    	for (Road road : roadList)
    	{
    		boolean hasRaceOnRoad = false;
    		for (Race race : this.racesList)
    		{
    			if (road.equals(race.getRoad())) {
    				hasRaceOnRoad = true;
    				break;
    			}
    		}
    		
    		if (!hasRaceOnRoad) {
        		Race race = new Race(road, road.getDescription());
        		// Persist and detach race to populate raceID
        		em.persist(race);
        		em.detach(race);
        		racesList.add(race);
        		newRaces = true;
    		}
    	}
    	if (newRaces)
    		sortRacesList();
    }
    
    /**
     * A user enters a race. Registers the user as one of the race contestants.
     * Default LockType for CONTAINER ConcurrencyManagement is WRITE.
     * If the user is the first to enter the race a timer is started on that race.
     * Other contestants can enter the race only if this race has not expired.
     */
    public void enterRace(User user, Long raceID)
    {
    	if (user == null)
    		throw new RuntimeException("Cannot enter race! User is null.");
    	else if (user.getMainCar()==null)
    		throw new RuntimeException("You must choose a main car berfore entering a race!");
    	
    	// Find the race using the raceID and check if user has already enrolled in other races
    	Race race = null;
    	boolean hasEnteredAlready = false;
    	for (Race r : racesList)
    	{
    		if (race==null && r.getRaceID().equals(raceID))
    			race = r;
    		if (r.getContestants().contains(user))
    			hasEnteredAlready = true;
    		if (hasEnteredAlready && race!=null)
    			break;
    	}
    	
    	if (hasEnteredAlready)
			throw new RuntimeException("You have already entered another race!");
    	else if (hasEnteredAlready && race != null && race.getRaceID().equals(raceID))
			throw new RuntimeException("You have already entered this race!");
    	
    	// Check if race is full or has already finished
    	if (race == null)
    		if (raceID != null && em.find(Race.class, raceID) != null)
    			throw new RuntimeException("Cannot enter race! Race is already over");
    		else
    			throw new RuntimeException("Race with id = " + raceID + "does not exist");
    	if (race.getRoad().getMaxNumPlayers() == race.getContestants().size())
    		throw new RuntimeException("Cannot enter race! Maximum number of contestants for this road has been reached!");
    	
    	// Add the user to the race contestants
    	race.getContestants().add(user);
    	
    	int numOfContestants = race.getContestants().size();
    	if (numOfContestants == 1) {
    		// Start timer until race starts 
        	Timer timer = timerService.createTimer(10000 * race.getRoad().getMaxNumPlayers(), raceID);
        	//Timer timer = timerService.createTimer(8000, raceID);
        	raceCountdownTimers.put(raceID, timer);
        	System.out.println("IN ENTER raceID = " + raceID);
    	}
    	else if (numOfContestants == race.getRoad().getMaxNumPlayers()) {
    		// If the road is full cancel race timer and start race
    		raceCountdownTimers.get(raceID).cancel();
    		raceCountdownTimers.remove(raceID);
    		race(race);
    	}
    }
    
    /**
     * Signal a start to the race if its countdown timer expires. 
     */
    @Timeout
    private void raceCountdownExpire(Timer timer)
    {
    	Long raceID = (Long)timer.getInfo();
    	System.out.println("IN TIMEOUT raceID = " + raceID);
    	
    	// No need to cancel the timer cause it is a single action timer
    	raceCountdownTimers.remove(raceID);
		race(raceID);
    }
    
    /**
     * Performs the race by calculating the standings given the road's 
     * and the contestant's car's attributes for top speed , acceleration and handling.
     * @see method race(Race race)
     */
    private void race(Long raceID)
    {
    	// Find the race using the raceID
    	Race race = null;
    	for (Race r : racesList) {
    		if (r.getRaceID().equals(raceID)) {
    			race = r;
    			break;
    		}
    	}
    	
    	// Check if race is full or has already finished
    	if (race == null)
    		if (raceID != null && em.find(Race.class, raceID) != null)
    			throw new RuntimeException("Cannot enter race! Race is already over");
    		else
    			throw new RuntimeException("Race with id = " + raceID + "does not exist");
    	
    	// Cancel race if there is a single contestant. Otherwise RACE!!! :)
    	if (race.getContestants()!=null && race.getContestants().size() == 1)
    		cancelRace(race.getContestants().get(0), raceID);
    	else
    		race(race);
    }
    
    /**
     * Performs the race by calculating the standings given the road's 
     * and the contestant's car's attributes for top speed , acceleration and handling.
     */
    private void race(Race race)
    {
    	Road road = race.getRoad();
    	List<User> contestants = race.getContestants();
    	
    	// Times are in milliseconds
    	long idealLapTimePer3000 = 85000;
    	long idealLapTime = (long)((road.getCircuitLength()/(double)3000) 
    									* idealLapTimePer3000); // For given road
    	
    	int idealUserXP = 10000;
    	int idealCarTopSpeed = 1000;
    	int idealCarAcceleration = 1000;
    	int idealCarHandling = 1000;
    	int idealCarCoef = idealCarTopSpeed * road.getTopSpeedCoef() + 
						   idealCarAcceleration * road.getAccelerationCoef() +
						   idealCarHandling * road.getHandlingCoef();
    	
    	List<RaceResult> raceResults = new ArrayList<RaceResult>();
    	for (User user : contestants)
    	{
    		Car userCar = user.getMainCar();
    		RaceResult raceResult = new RaceResult(race, user, userCar);
    		int userCarCoef = userCar.getTopSpeed() * road.getTopSpeedCoef() + 
    						  userCar.getAcceleration() * road.getAccelerationCoef() +
    						  userCar.getHandling() * road.getHandlingCoef();
    		long userLapTime = (long)(1 - (userCarCoef/(double)idealCarCoef) * idealLapTime) + idealLapTime;
    		long userRaceTime = road.getNumberOfLaps() * userLapTime;
    		
    		// Decrease user race time depending on the contestant's XP
    		long subtractTime = (long)((user.getExperience()/(double)idealUserXP) * userLapTime);
    		userRaceTime -= subtractTime;
    		raceResult.setTime(userRaceTime);
    		raceResults.add(raceResult);
    	}
    	
    	Collections.sort(raceResults, new Comparator<RaceResult>() {
    			public int compare(RaceResult r1, RaceResult r2){
    				return (r1.getTime()>r2.getTime() ? 1 :
    							(r1.getTime()==r2.getTime() && 
    							 r1.getUser().getExperience()>r2.getUser().getExperience() ? 1 : -1)); 
    			}
    		});
    	
    	// Update position standings and users' XPs and budgets 
    	long pos = 0;
    	Integer award = road.getAward();
    	for (RaceResult raceResult : raceResults) {
    		raceResult.setPosition(++pos);
    		User user = raceResult.getUser();
    		user.setExperience(user.getExperience() + 20);
    		if (award > 0) {
    			user.setBudget(user.getBudget() + award);
    			award -= 50;
    		}
    		em.merge(user);
    	}
    	
    	// Persist race
    	race.setRaceResults(raceResults);
    	race.setRaceTime(new Date());
    	em.merge(race);
    	
    	// Update road record if necessary
    	if (road.getBestRace() == null || 
    			road.getBestRace().getSortedRaceResults().get(0).getTime() > raceResults.get(0).getTime())
    	{
    		road.setBestRace(race);
    		em.merge(road);
    	}
    	
    	// Create a new empty race for the current road
    	Race newRace = new Race(road, road.getDescription());
    	em.persist(newRace); // Populate raceID
    	em.detach(newRace);
    	
    	this.racesList.remove(race);
    	this.racesList.add(newRace);
    	sortRacesList();
    }
    
    /**
     * Called when a user changes his mind and cancels the race
     */
    public void cancelRace(User user, Long raceID)
    {
    	if (user == null)
    		throw new RuntimeException("Cannot cancel race! User is null.");
    	
    	// Find the race using the raceID and check if user has already enrolled in other races
    	Race race = null;
    	for (Race r : racesList)
    		if (r.getRaceID().equals(raceID)) {
    			race = r;
    			break;
    		}
    	
    	// Check if race is full or has already finish
    	if (race == null)
    		if (raceID != null && em.find(Race.class, raceID) != null)
    			throw new RuntimeException("Cannot cancel race! Race is already over");
    		else
    			throw new RuntimeException("Race with id = " + raceID + "does not exist");
    	else if (!race.getContestants().contains(user))
    		throw new RuntimeException("You have not entered this race to cancel it!");

    	race.getContestants().remove(user);
    	
    	// Stop the race countdown timer if the user had been the only one in the race
    	if (race.getContestants().size() == 0) {
    		Timer raceTimer = raceCountdownTimers.get(raceID);
    		if (raceTimer != null) {
    			raceTimer.cancel();
    			raceCountdownTimers.remove(raceID);
    		}	
			// Create a new empty race for cancelled road
			Road road = race.getRoad();
	    	Race newRace = new Race(road, road.getDescription());
	    	em.persist(newRace); // Populate raceID
	    	em.detach(newRace);
	    	
	    	this.racesList.remove(race);
	    	this.racesList.add(newRace);
	    	sortRacesList();
    	}
    }

	/**
	 * Returns the remaining time in seconds before the start of a given race.
	 * Returns -1 of race timer has not been started yet. 
	 */
    @Lock(LockType.READ)
	public int secondsToStart(Long raceID)
	{
		Timer raceCountdownTimer = raceCountdownTimers.get(raceID);
		if (raceCountdownTimer != null)
			return (int)raceCountdownTimer.getTimeRemaining()/1000;
		return -1;
	}

    /**
     * Checks race in DB
     */
    @Lock(LockType.READ)
    public boolean isRaceInHistory(Long raceID)
    {
    	return raceID != null && em.find(Race.class, raceID) != null;
    }
    
    @Lock(LockType.READ)
	public List<Race> getRacesList() {
		return racesList;
	}

    @Lock(LockType.READ)
	public Map<Long, Timer> getRaceCountdownTimers() {
		return raceCountdownTimers;
	}
}
